[v1.x] Give recursive tool return types an object-rooted output schema - #3377
Conversation
pydantic emits a self-referential model as {"$defs": {...}, "$ref": "#/$defs/Model"}
with no type at the root. Tool.outputSchema requires type: object at the root, and
strict clients (TypeScript SDK 1.x, C# SDK 1.x, python-sdk 2.x on a 2025-11-25
session) reject the entire tools/list result when one tool publishes that shape.
Inline the referenced definition onto the root when the generated schema is a
bare local $ref, keeping $defs for the nested references. Backport of the fix on
main.
Github-Issue: #3337
| ref = schema.get("$ref") | ||
| if not isinstance(ref, str) or not ref.startswith(_LOCAL_DEFS_PREFIX): | ||
| return schema | ||
| definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]) |
There was a problem hiding this comment.
🟡 Minor/edge: _inline_root_ref does unguarded schema["$defs"][name] lookups, so a root-level $ref without a matching local definition raises KeyError instead of being passed through
Extended reasoning...
A user customizes a return model's schema (e.g. model_config = ConfigDict(json_schema_extra={"$ref": "#/$defs/X"}) or a custom schema generator) so the generated schema has a root $ref starting with #/$defs/ but no $defs key or no X entry. Before this change the schema was published as-is; after it, func_metadata raises an uncaught KeyError inside _try_create_model_and_schema (the surrounding try only wraps model_json_schema), so mcp.tool() registration crashes at import/startup instead of registering the tool.
Verification: nit — Line 62 of src/mcp/server/fastmcp/utilities/func_metadata.py performs unguarded lookups: definition = cast(dict[str, Any], schema["$defs"][ref.removeprefix(_LOCAL_DEFS_PREFIX)]). The guard at lines 59-61 only checks that $ref is a string starting with "#/$defs/"; it never checks that $defs exists or contains the referenced name. The enclosing try/except in `_try_create_model_and_sc
Backport of #3376 to the v1.x line. Refs #3337.
When a FastMCP tool's return type is self-referential, pydantic emits the output schema as
{"$defs": {...}, "$ref": "#/$defs/Node"}with notypeat the root.Tool.outputSchemarequirestype: "object"at the root on every protocol version v1.x speaks, and strict clients — TypeScript SDK 1.x, C# SDK 1.x, and python-sdk 2.x on a 2025-11-25 session — reject the entiretools/listresult when one tool publishes that shape. This inlines the referenced definition onto the root and keeps$defsfor the nested references.Motivation and Context
On v1.x nothing errors on the Python side (
Tool.outputSchemaisdict[str, Any]and isn't shape-checked), so the failure shows up in the peer: a host embedding the TypeScript SDK 1.x sees the server as having no tools at all. That's the same report as PrefectHQ/fastmcp#2455. Given the blast radius against the most common client family and the size of the change, this seemed worth carrying on the maintenance line.How Has This Been Tested?
test_structured_output_self_referential_model_gets_an_object_rootpins the generated shape and round-trips a nested result.test_tool_structured_output_self_referential_modeldrives an in-memory client session throughtools/listandtools/call; both fail before the change.ListToolsResultto the TypeScript SDK 1.xListToolsResultSchema: accepted after the change,tools.0.outputSchema.type Invalid input: expected "object"before. Also valid against the spec'sschema/2025-11-25/schema.json.Breaking Changes
None. The only observable change is the JSON content of
outputSchemafor recursive return types: the root gains the definition's keys,$defsis unchanged,structuredContentis unchanged.Types of changes
Checklist
Additional context
Same out-of-scope note as #3376: a
RootModelwhose root isn't an object still publishes a non-object root.AI Disclaimer